// Copyright (c) 2011, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

part of touch;

/// Implementation of a custom scrolling behavior.
/// This behavior overrides native scrolling for an area. This area can be a
/// single defined part of a page, the entire page, or several different parts
/// of a page.
///
/// To use this scrolling behavior you need to define a frame and the content.
/// The frame defines the area that the content will scroll within. The frame and
/// content must both be HTML Elements, with the content being a direct child of
/// the frame. Usually the frame is smaller in size than the content. This is
/// not necessary though, if the content is smaller then bouncing will occur to
/// provide feedback that you are past the scrollable area.
///
/// The scrolling behavior works using the webkit translate3d transformation,
/// which means browsers that do not have hardware accelerated transformations
/// will not perform as well using this. Simple scrolling should be fine even
/// without hardware acceleration, but animating momentum and deceleration is
/// unacceptably slow without it. There is also the option to use relative
/// positioning (setting the left and top styles).
///
/// For this to work properly you need to set -webkit-text-size-adjust to 'none'
/// on an ancestor element of the frame, or on the frame itself. If you forget
/// this you may see the text content of the scrollable area changing size as it
/// moves.
///
/// The behavior is intended to support vertical and horizontal scrolling, and
/// scrolling with momentum when a touch gesture flicks with enough velocity.
typedef Callback = void Function();

// Helper method to await the completion of 2 futures.
void joinFutures(List<Future> futures, Callback callback) {
  int count = 0;
  int len = futures.length;
  void helper(value) {
    count++;
    if (count == len) {
      callback();
    }
  }

  for (Future p in futures) {
    p.then(helper);
  }
}

class Scroller implements Draggable, MomentumDelegate {
  /// Pixels to move each time an arrow key is pressed. */
  static const ARROW_KEY_DELTA = 30;
  static const SCROLL_WHEEL_VELOCITY = 0.01;
  static const FAST_SNAP_DECELERATION_FACTOR = 0.84;
  static const PAGE_KEY_SCROLL_FRACTION = .85;

  // TODO(jacobr): remove this static variable.
  static bool _dragInProgress = false;

  /// The node that will actually scroll. */
  final Element _element;

  /// Frame is the node that will serve as the container for the scrolling
  /// content.
  final Element _frame;

  /// Touch manager to track the events on the scrollable area. */
  late final TouchHandler _touchHandler;

  late final Momentum _momentum;

  late final StreamController<Event> _onScrollerStart =
      StreamController<Event>.broadcast(sync: true);
  late final Stream<Event> onScrollerStart = _onScrollerStart.stream;
  late final StreamController<Event> _onScrollerEnd =
      StreamController<Event>.broadcast(sync: true);
  late final Stream<Event> onScrollerEnd = _onScrollerEnd.stream;
  late final StreamController<Event> _onScrollerDragEnd =
      StreamController<Event>.broadcast(sync: true);
  late final Stream<Event> onScrollerDragEnd = _onScrollerDragEnd.stream;
  late final StreamController<Event> _onContentMoved =
      StreamController<Event>.broadcast(sync: true);
  late final Stream<Event> onContentMoved = _onContentMoved.stream;
  late final StreamController<Event> _onDecelStart =
      StreamController<Event>.broadcast(sync: true);
  late final Stream<Event> onDecelStart = _onDecelStart.stream;

  /// Set if vertical scrolling should be enabled. */
  @override
  bool verticalEnabled;

  /// Set if horizontal scrolling should be enabled. */
  @override
  bool horizontalEnabled;

  /// Set if momentum should be enabled.
  bool _momentumEnabled;

  /// Set which type of scrolling translation technique should be used. */
  final int _scrollTechnique;

  /// The maximum coordinate that the left upper corner of the content can scroll
  /// to.
  Coordinate _maxPoint;

  /// An offset to subtract from the maximum coordinate that the left upper
  /// corner of the content can scroll to.
  final Coordinate _maxOffset;

  /// An offset to add to the minimum coordinate that the left upper corner of
  /// the content can scroll to.
  final Coordinate _minOffset;

  /// Initialize the current content offset. */
  final Coordinate _contentOffset;

  // TODO(jacobr): the function type is
  // [:Function(Element, num, num)->void:].
  /// The function to use that will actually translate the scrollable node.
  late final Function _setOffsetFunction;

  /// Function that returns the content size that can be specified instead of
  /// querying the DOM.
  final Function _lookupContentSizeDelegate;

  late Size _scrollSize;
  late Size _contentSize;
  Coordinate _minPoint;
  final bool _isStopping = false;
  Coordinate? _contentStartOffset;
  bool _started = false;
  bool _activeGesture = false;
  late final ScrollWatcher _scrollWatcher = ScrollWatcher(this)..initialize();

  Scroller(
    Element scrollableElem, [
    this.verticalEnabled = false,
    this.horizontalEnabled = false,
    momentumEnabled = true,
    lookupContentSizeDelegate,
    num defaultDecelerationFactor = 1,
    int? scrollTechnique,
    bool capture = false,
  ]) : _momentumEnabled = momentumEnabled,
       _lookupContentSizeDelegate = lookupContentSizeDelegate,
       _element = scrollableElem,
       _frame = scrollableElem.parent!,
       _scrollTechnique =
           scrollTechnique ?? ScrollerScrollTechnique.TRANSFORM_3D,
       _minPoint = Coordinate(0, 0),
       _maxPoint = Coordinate(0, 0),
       _maxOffset = Coordinate(0, 0),
       _minOffset = Coordinate(0, 0),
       _contentOffset = Coordinate(0, 0) {
    _touchHandler = TouchHandler(this, scrollableElem.parent);
    _momentum = Momentum(this, defaultDecelerationFactor);

    Element parentElem = scrollableElem.parent!;
    _setOffsetFunction = _getOffsetFunction(_scrollTechnique);
    _touchHandler.setDraggable(this);
    _touchHandler.enable(capture);

    _frame.onMouseWheel.listen((e) {
      if (e.deltaY != 0 && verticalEnabled ||
          e.deltaX != 0 && horizontalEnabled) {
        num x = horizontalEnabled ? e.deltaX : 0;
        num y = verticalEnabled ? e.deltaY : 0;
        throwDelta(x, y, FAST_SNAP_DECELERATION_FACTOR);
        e.preventDefault();
      }
    });

    _frame.onKeyDown.listen((KeyboardEvent e) {
      bool handled = false;
      // We ignore key events where further scrolling in that direction
      // would have no impact which matches default browser behavior with
      // nested scrollable areas.

      switch (e.keyCode) {
        case 33: // page-up
          throwDelta(0, _scrollSize.height * PAGE_KEY_SCROLL_FRACTION);
          handled = true;
          break;
        case 34: // page-down
          throwDelta(0, -_scrollSize.height * PAGE_KEY_SCROLL_FRACTION);
          handled = true;
          break;
        case 35: // End
          throwTo(_maxPoint.x, _minPoint.y, FAST_SNAP_DECELERATION_FACTOR);
          handled = true;
          break;
        case 36: // Home
          throwTo(_maxPoint.x, _maxPoint.y, FAST_SNAP_DECELERATION_FACTOR);
          handled = true;
          break;
        /* TODO(jacobr): enable arrow keys when the don't conflict with other
   application keyboard shortcuts.
          case 38: // up
            handled = throwDelta(
                0,
                ARROW_KEY_DELTA,
                FAST_SNAP_DECELERATION_FACTOR);
            break;
          case 40: // down
            handled = throwDelta(
                0, -ARROW_KEY_DELTA,
                FAST_SNAP_DECELERATION_FACTOR);
            break;
          case 37: // left
            handled = throwDelta(
                ARROW_KEY_DELTA, 0,
                FAST_SNAP_DECELERATION_FACTOR);
            break;
          case 39: // right
            handled = throwDelta(
                -ARROW_KEY_DELTA,
                0,
                FAST_SNAP_DECELERATION_FACTOR);
            break;
            */
      }
      if (handled) {
        e.preventDefault();
      }
    });
    // The scrollable element must be relatively positioned.
    // TODO(jacobr): this assert fires asynchronously which could be confusing.
    if (_scrollTechnique == ScrollerScrollTechnique.RELATIVE_POSITIONING) {
      assert(_element.getComputedStyle().position != "static");
    }

    _initLayer();
  }

  /// Add a scroll listener. This allows other classes to subscribe to scroll
  /// notifications from this scroller.
  void addScrollListener(ScrollListener listener) {
    _scrollWatcher.addListener(listener);
  }

  /// Adjust the new calculated scroll position based on the minimum allowed
  /// position and returns the adjusted scroll value.
  num _adjustValue(num newPosition, num minPosition, num maxPosition) {
    assert(minPosition <= maxPosition);

    if (newPosition < minPosition) {
      newPosition -= (newPosition - minPosition) / 2;
    } else {
      if (newPosition > maxPosition) {
        newPosition -= (newPosition - maxPosition) / 2;
      }
    }
    return newPosition;
  }

  /// Coordinate we would end up at if we did nothing.
  Coordinate get currentTarget {
    Coordinate? end = _momentum.destination;
    end ??= _contentOffset;
    return end;
  }

  Coordinate get contentOffset => _contentOffset;

  /// Animate the position of the scroller to the specified [x], [y] coordinates
  /// by applying the throw gesture with the correct velocity to end at that
  /// location.
  void throwTo(num x, num y, [num? decelerationFactor]) {
    reconfigure(() {
      final snappedTarget = _snapToBounds(x, y);
      // If a deceleration factor is not specified, use the existing
      // deceleration factor specified by the momentum simulator.
      decelerationFactor ??= _momentum.decelerationFactor;

      if (snappedTarget != currentTarget) {
        _momentum.abort();

        _startDeceleration(
          _momentum.calculateVelocity(
            _contentOffset,
            snappedTarget,
            decelerationFactor,
          ),
          decelerationFactor,
        );
        if (_onDecelStart != null) {
          _onDecelStart.add(Event(ScrollerEventType.DECEL_START));
        }
      }
    });
  }

  void throwDelta(num deltaX, num deltaY, [num? decelerationFactor]) {
    Coordinate start = _contentOffset;
    Coordinate end = currentTarget;
    int x = end.x.toInt();
    int y = end.y.toInt();
    // If we are throwing in the opposite direction of the existing momentum,
    // cancel the current momentum.
    if (deltaX != 0 && deltaX.isNegative != (end.x - start.x).isNegative) {
      x = start.x as int;
    }
    if (deltaY != 0 && deltaY.isNegative != (end.y - start.y).isNegative) {
      y = start.y as int;
    }
    x += deltaX.toInt();
    y += deltaY.toInt();
    throwTo(x, y, decelerationFactor);
  }

  void setPosition(num x, num y) {
    _momentum.abort();
    _contentOffset.x = x;
    _contentOffset.y = y;
    _snapContentOffsetToBounds();
    _setContentOffset(_contentOffset.x, _contentOffset.y);
  }

  /// Adjusted content size is a size with the combined largest height and width
  /// of both the content and the frame.
  Size _getAdjustedContentSize() {
    return Size(
      Math.max(_scrollSize.width, _contentSize.width),
      Math.max(_scrollSize.height, _contentSize.height),
    );
  }

  // TODO(jmesserly): these should be properties instead of get* methods
  num getDefaultVerticalOffset() => _maxPoint.y;
  @override
  Element getElement() => _element;
  Element getFrame() => _frame;
  num getHorizontalOffset() => _contentOffset.x;

  /// [x] Value to use as reference for percent measurement. If
  ///      none is provided then the content's current x offset will be used.
  /// Returns the percent of the page scrolled horizontally.
  num getHorizontalScrollPercent([num? x]) {
    x = x ?? _contentOffset.x;
    return (x - _minPoint.x) / (_maxPoint.x - _minPoint.x);
  }

  num getMaxPointY() => _maxPoint.y;
  num getMinPointY() => _minPoint.y;
  Momentum get momentum => _momentum;

  /// Provide access to the touch handler that the scroller created to manage
  /// touch events.
  TouchHandler getTouchHandler() => _touchHandler;
  num getVerticalOffset() => _contentOffset.y;

  /// [y] value is used as reference for percent measurement. If
  /// none is provided then the content's current y offset will be used.
  num getVerticalScrollPercent([num? y]) {
    y = y ?? _contentOffset.y;
    return (y - _minPoint.y) / Math.max(1, _maxPoint.y - _minPoint.y);
  }

  /// Initialize the dom elements necessary for the scrolling to work.
  void _initLayer() {
    // The scrollable node provided to Scroller must be a direct child
    // of the scrollable frame.
    // TODO(jacobr): Figure out why this is failing on dartium.
    // assert(_element.parent == _frame);
    _setContentOffset(_maxPoint.x, _maxPoint.y);
  }

  @override
  void onDecelerate(num x, num y) {
    _setContentOffset(x, y);
  }

  @override
  void onDecelerationEnd() {
    if (_onScrollerEnd != null) {
      _onScrollerEnd.add(Event(ScrollerEventType.SCROLLER_END));
    }
    _started = false;
  }

  @override
  void onDragEnd() {
    _dragInProgress = false;

    bool decelerating = false;
    if (_activeGesture) {
      if (_momentumEnabled) {
        decelerating = _startDeceleration(_touchHandler.getEndVelocity());
      }
    }

    if (_onScrollerDragEnd != null) {
      _onScrollerDragEnd.add(Event(ScrollerEventType.DRAG_END));
    }

    if (!decelerating) {
      _snapContentOffsetToBounds();
      if (_onScrollerEnd != null) {
        _onScrollerEnd.add(Event(ScrollerEventType.SCROLLER_END));
      }
      _started = false;
    } else {
      if (_onDecelStart != null) {
        _onDecelStart.add(Event(ScrollerEventType.DECEL_START));
      }
    }
    _activeGesture = false;
  }

  @override
  void onDragMove() {
    if (_isStopping || (!_activeGesture && _dragInProgress)) {
      return;
    }

    Coordinate contentStart = _contentStartOffset!;
    num newX = contentStart.x + _touchHandler.getDragDeltaX();
    num newY = contentStart.y + _touchHandler.getDragDeltaY();
    newY =
        _shouldScrollVertically()
            ? _adjustValue(newY, _minPoint.y, _maxPoint.y)
            : 0;
    newX =
        _shouldScrollHorizontally()
            ? _adjustValue(newX, _minPoint.x, _maxPoint.x)
            : 0;
    if (!_activeGesture) {
      _activeGesture = true;
      _dragInProgress = true;
    }
    if (!_started) {
      _started = true;
      if (_onScrollerStart != null) {
        _onScrollerStart.add(Event(ScrollerEventType.SCROLLER_START));
      }
    }
    _setContentOffset(newX, newY);
  }

  @override
  bool onDragStart(TouchEvent e) {
    if (e.touches!.length > 1) {
      return false;
    }
    bool shouldHorizontal = _shouldScrollHorizontally();
    bool shouldVertical = _shouldScrollVertically();
    bool verticalish =
        _touchHandler.getDragDeltaY().abs() >
        _touchHandler.getDragDeltaX().abs();
    return !!(shouldVertical || shouldHorizontal && !verticalish);
  }

  @override
  void onTouchEnd() {}

  /// Prepare the scrollable area for possible movement.
  @override
  bool onTouchStart(TouchEvent e) {
    reconfigure(() {
      final touch = e.touches![0];
      if (_momentum.decelerating) {
        e.preventDefault();
        e.stopPropagation();
        stop();
      }
      _contentStartOffset = _contentOffset.clone();
      _snapContentOffsetToBounds();
    });
    return true;
  }

  /// Recalculate dimensions of the frame and the content. Adjust the minPoint
  /// and maxPoint allowed for scrolling and scroll to a valid position. Call
  /// this method if you know the frame or content has been updated. Called
  /// internally on every touchstart event the frame receives.
  void reconfigure(Callback callback) {
    _resize(() {
      _snapContentOffsetToBounds();
      callback();
    });
  }

  void reset() {
    stop();
    _touchHandler.reset();
    _maxOffset.x = 0;
    _maxOffset.y = 0;
    _minOffset.x = 0;
    _minOffset.y = 0;
    reconfigure(() => _setContentOffset(_maxPoint.x, _maxPoint.y));
  }

  /// Recalculate dimensions of the frame and the content. Adjust the minPoint
  /// and maxPoint allowed for scrolling.
  void _resize(Callback callback) {
    scheduleMicrotask(() {
      if (_lookupContentSizeDelegate != null) {
        _contentSize = _lookupContentSizeDelegate();
      } else {
        _contentSize = Size(_element.scrollWidth, _element.scrollHeight);
      }

      _scrollSize = Size(_frame.offset.width, _frame.offset.height);
      Size adjusted = _getAdjustedContentSize();
      _maxPoint = Coordinate(-_maxOffset.x, -_maxOffset.y);
      _minPoint = Coordinate(
        Math.min(
          _scrollSize.width - adjusted.width + _minOffset.x,
          _maxPoint.x,
        ),
        Math.min(
          _scrollSize.height - adjusted.height + _minOffset.y,
          _maxPoint.y,
        ),
      );
      callback();
    });
  }

  Coordinate _snapToBounds(num x, num y) {
    num clampX = GoogleMath.clamp(_minPoint.x, x, _maxPoint.x);
    num clampY = GoogleMath.clamp(_minPoint.y, y, _maxPoint.y);
    return Coordinate(clampX, clampY);
  }

  /// Translate the content to a new position specified in px.
  void _setContentOffset(num x, num y) {
    _contentOffset.x = x;
    _contentOffset.y = y;
    _setOffsetFunction(_element, x, y);
    if (_onContentMoved != null) {
      _onContentMoved.add(Event(ScrollerEventType.CONTENT_MOVED));
    }
  }

  /// Enable or disable momentum.
  void setMomentum(bool enable) {
    _momentumEnabled = enable;
  }

  /// Sets the vertical scrolled offset of the element where [y] is the amount
  /// of vertical space to be scrolled, in pixels.
  void setVerticalOffset(num y) {
    _setContentOffset(_contentOffset.x, y);
  }

  /// Whether the scrollable area should scroll horizontally. Only
  /// returns true if the client has enabled horizontal scrolling, and the
  /// content is wider than the frame.
  bool _shouldScrollHorizontally() {
    return horizontalEnabled && _scrollSize.width < _contentSize.width;
  }

  /// Whether the scrollable area should scroll vertically. Only
  /// returns true if the client has enabled vertical scrolling.
  /// Vertical bouncing will occur even if frame is taller than content, because
  /// this is what iPhone web apps tend to do. If this is not the desired
  /// behavior, either disable vertical scrolling for this scroller or add a
  /// 'bouncing' parameter to this interface.
  bool _shouldScrollVertically() {
    return verticalEnabled;
  }

  /// In the event that the content is currently beyond the bounds of
  /// the frame, snap it back in to place.
  void _snapContentOffsetToBounds() {
    num clampX = GoogleMath.clamp(_minPoint.x, _contentOffset.x, _maxPoint.x);
    num clampY = GoogleMath.clamp(_minPoint.y, _contentOffset.y, _maxPoint.y);
    if (_contentOffset.x != clampX || _contentOffset.y != clampY) {
      _setContentOffset(clampX, clampY);
    }
  }

  /// Initiate the deceleration behavior given a flick [velocity].
  /// Returns true if deceleration has been initiated.
  bool _startDeceleration(Coordinate velocity, [num? decelerationFactor]) {
    if (!_shouldScrollHorizontally()) {
      velocity.x = 0;
    }
    if (!_shouldScrollVertically()) {
      velocity.y = 0;
    }
    assert(_minPoint != null); // Min point is not set
    assert(_maxPoint != null); // Max point is not set
    return _momentum.start(
      velocity,
      _minPoint,
      _maxPoint,
      _contentOffset,
      decelerationFactor,
    );
  }

  Coordinate stop() {
    return _momentum.stop();
  }

  /// Stop the deceleration of the scrollable content given a new position in px.
  void _stopDecelerating(num x, num y) {
    _momentum.stop();
    _setContentOffset(x, y);
  }

  static Function _getOffsetFunction(int scrollTechnique) {
    return scrollTechnique == ScrollerScrollTechnique.TRANSFORM_3D
        ? (el, x, y) {
          FxUtil.setTranslate(el, x, y, 0);
        }
        : (el, x, y) {
          FxUtil.setLeftAndTop(el, x, y);
        };
  }
}

// TODO(jacobr): cleanup this class of enum constants.
class ScrollerEventType {
  static const SCROLLER_START = "scroller:scroll_start";
  static const SCROLLER_END = "scroller:scroll_end";
  static const DRAG_END = "scroller:drag_end";
  static const CONTENT_MOVED = "scroller:content_moved";
  static const DECEL_START = "scroller:decel_start";
}

class ScrollerScrollTechnique {
  static const TRANSFORM_3D = 1;
  static const RELATIVE_POSITIONING = 2;
}
